<!doctype html>
<html lang="zh-CN">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width,initial-scale=1" />
  <title>Browser-Use 控制台（Manus风格）</title>
  <script src="https://cdn.tailwindcss.com"></script>
  <link rel="icon" href="data:," />
  <style>
    /* 细节优化 */
    .log-pane { font-feature-settings: "calt" 0; }
    .mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono","Courier New", monospace; }
    .dot {
      width: 10px; height: 10px; border-radius: 9999px;
      display: inline-block; margin-right: .5rem;
    }
    .status-idle   { background:#9ca3af }
    .status-run    { background:#16a34a }
    .status-error  { background:#dc2626 }
    .status-agent  { background:#06b6d4 }
    .status-human  { background:#f59e0b }
    /* 画面层的“接管中”提示 */
    .overlay {
      position:absolute; inset:0; pointer-events:none;
      display:flex; align-items:center; justify-content:center;
      background: rgba(0,0,0,0.0); transition: background .2s ease;
    }
    .overlay.show { background: rgba(0,0,0,0.1); }
    .floating-tip {
      backdrop-filter: blur(6px);
      background: rgba(0,0,0,0.45);
      color: #fff; padding: .35rem .6rem; border-radius: .5rem;
      font-size: .8rem;
    }
    /* 让画布保持等比缩放、容器可自适应 */
    .screen-wrap { position: relative; }
    canvas { display:block; width:100%; height:auto; background:#0b1020; border-radius: 1rem; }
    /* 滚动条美化（仅支持现代浏览器） */
    .nice-scroll::-webkit-scrollbar { width: 10px; height:10px }
    .nice-scroll::-webkit-scrollbar-thumb { background:#CBD5E1; border-radius: 8px }
    .nice-scroll::-webkit-scrollbar-track { background:#F1F5F9 }
  </style>
</head>
<body class="bg-slate-50 text-slate-800">
  <!-- 顶栏 -->
  <header class="sticky top-0 z-30 border-b bg-white/80 backdrop-blur">
    <div class="max-w-7xl mx-auto px-4 py-3 flex items-center justify-between">
      <h1 class="text-xl font-semibold">Browser-Use 控制台 <span class="text-slate-400 text-sm">（Manus 风格）</span></h1>
      <div class="flex items-center gap-2 text-sm">
        <span class="dot status-idle" id="status-dot"></span>
        <span id="status-text" class="mr-4">状态：未连接</span>
        <span class="px-2 py-1 rounded bg-slate-100" id="fps">FPS: --</span>
        <span class="px-2 py-1 rounded bg-slate-100" id="rtt">延迟: -- ms</span>
      </div>
    </div>
  </header>

  <!-- 主体 -->
  <main class="max-w-7xl mx-auto px-4 py-6 grid lg:grid-cols-3 gap-6">
    <!-- 左侧：视频/控制 -->
    <section class="lg:col-span-2 space-y-4">
      <!-- 连接与会话 -->
      <div class="p-4 rounded-2xl bg-white shadow-sm border">
        <div class="grid md:grid-cols-4 gap-3">
          <div class="md:col-span-2">
            <label class="text-sm text-slate-500">服务 Base URL</label>
            <input id="baseUrl" class="mt-1 w-full rounded-lg border px-3 py-2" placeholder="http://localhost:8000" value="http://localhost:8000" />
          </div>
          <div>
            <label class="text-sm text-slate-500">X-API-Key</label>
            <input id="apiKey" class="mt-1 w-full rounded-lg border px-3 py-2" placeholder="dev-key" value="dev-key" />
          </div>
          <div class="flex items-end gap-2">
            <button id="btnCreate" class="w-full rounded-xl bg-indigo-600 text-white px-4 py-2 hover:bg-indigo-500">创建会话</button>
          </div>
          <div class="md:col-span-3">
            <label class="text-sm text-slate-500">Session ID</label>
            <input id="sessionId" class="mt-1 w-full rounded-lg border px-3 py-2 mono" placeholder="创建后自动填入，或手动粘贴已有 ID" />
          </div>
          <div class="flex items-end gap-2">
            <button id="btnConnect" class="w-full rounded-xl bg-slate-800 text-white px-4 py-2 hover:bg-slate-700">连接WS</button>
          </div>
        </div>
      </div>

      <!-- 画面与操控 -->
      <div class="p-4 rounded-2xl bg-white shadow-sm border">
        <div class="flex items-center justify-between mb-3">
          <div class="flex items-center gap-3">
            <span class="px-2 py-1 rounded bg-cyan-100 text-cyan-800 text-sm" id="modeBadge">模式：Agent</span>
            <span class="px-2 py-1 rounded bg-amber-100 text-amber-800 text-sm hidden" id="opBadge">操作者：你</span>
          </div>
          <div class="flex items-center gap-2">
            <button id="btnHandover" class="rounded-xl bg-amber-500 text-white px-4 py-2 hover:bg-amber-400">接管</button>
            <button id="btnRelease" class="rounded-xl bg-emerald-600 text-white px-4 py-2 hover:bg-emerald-500" disabled>释放</button>
          </div>
        </div>

        <div class="screen-wrap">
          <canvas id="screen" width="1024" height="640"></canvas>
          <div id="overlay" class="overlay"><div class="floating-tip opacity-0 transition-opacity">单击画面以聚焦，键盘事件才会注入</div></div>
        </div>

        <div class="flex items-center justify-between mt-3 text-sm text-slate-500">
          <div>画面尺寸：<span id="canvasSize">1024×640</span>（自动等比缩放）</div>
          <div>提示：接管后鼠标/键盘会被注入远端浏览器</div>
        </div>
      </div>

      <!-- 任务区域 -->
      <div class="p-4 rounded-2xl bg-white shadow-sm border">
        <div class="flex items-center justify-between mb-3">
          <h2 class="font-medium">下发任务</h2>
          <div class="text-sm text-slate-500">DeepSeek 在后端已配置：只需提交自然语言任务</div>
        </div>
        <div class="grid md:grid-cols-6 gap-3">
          <textarea id="task" class="md:col-span-5 w-full h-28 rounded-lg border p-3" placeholder="例如：打开 https://example.org 并读取页面标题，返回结果"></textarea>
          <div class="flex md:flex-col gap-2">
            <input id="maxSteps" type="number" min="1" value="30" class="w-full rounded-lg border p-2" title="max_steps" />
            <button id="btnSendTask" class="w-full rounded-xl bg-indigo-600 text-white px-4 py-2 hover:bg-indigo-500">提交任务</button>
          </div>
        </div>
      </div>
    </section>

    <!-- 右侧：日志/调试 -->
    <aside class="space-y-4">
      <div class="p-4 rounded-2xl bg-white shadow-sm border">
        <div class="flex items-center justify-between mb-2">
          <h2 class="font-medium">事件日志</h2>
          <div class="flex items-center gap-2">
            <button id="btnClearLog" class="text-sm px-3 py-1 rounded-lg border hover:bg-slate-50">清空</button>
            <label class="text-sm flex items-center gap-2">
              <input type="checkbox" id="autoScroll" checked />
              自动滚动
            </label>
          </div>
        </div>
        <div id="logs" class="log-pane nice-scroll h-[520px] overflow-auto mono text-[12.5px] leading-5 bg-slate-50 rounded-xl p-3"></div>
      </div>

      <div class="p-4 rounded-2xl bg-white shadow-sm border">
        <h2 class="font-medium mb-2">调试工具</h2>
        <div class="grid grid-cols-2 gap-2">
          <button id="btnPing" class="rounded-lg border px-3 py-2 hover:bg-slate-50">测RTT</button>
          <button id="btnSnap" class="rounded-lg border px-3 py-2 hover:bg-slate-50">保存当前帧</button>
        </div>
      </div>
    </aside>
  </main>

  <footer class="max-w-7xl mx-auto px-4 pb-10 text-xs text-slate-500">
    © Browser-Use Console · DeepSeek + FastAPI + CDP Screencast
  </footer>

  <script>
    // ============ DOM refs ============
    const $ = (id) => document.getElementById(id);
    const baseUrl     = $("baseUrl");
    const apiKey      = $("apiKey");
    const btnCreate   = $("btnCreate");
    const sessionId   = $("sessionId");
    const btnConnect  = $("btnConnect");
    const btnHandover = $("btnHandover");
    const btnRelease  = $("btnRelease");
    const modeBadge   = $("modeBadge");
    const opBadge     = $("opBadge");
    const statusDot   = $("status-dot");
    const statusText  = $("status-text");
    const canvas      = $("screen");
    const overlay     = $("overlay");
    const canvasSize  = $("canvasSize");
    const task        = $("task");
    const maxSteps    = $("maxSteps");
    const btnSendTask = $("btnSendTask");
    const logs        = $("logs");
    const btnClearLog = $("btnClearLog");
    const autoScroll  = $("autoScroll");
    const btnPing     = $("btnPing");
    const btnSnap     = $("btnSnap");
    const fpsEl       = $("fps");
    const rttEl       = $("rtt");

    // ============ State ============
    let wsEvents = null, wsScreen = null, wsControl = null;
    let connected = false, operator = false, mode = "agent"; // "agent" | "human"
    let fpsCounter = 0, lastFpsTs = performance.now();
    let lastRttSent = 0;

    // 画面渲染（把 base64 JPEG 画到 canvas）
    const ctx = canvas.getContext("2d");
    const frameImg = new Image();
    frameImg.onload = () => {
      // 自适应高度：保持宽度固定，按等比更新 canvas 高度（或你可改为固定 height）
      const w = canvas.width;
      const ratio = frameImg.height / frameImg.width;
      const h = Math.round(w * ratio);
      if (canvas.height !== h) {
        canvas.height = h;
        canvasSize.textContent = `${w}×${h}`;
      }
      ctx.drawImage(frameImg, 0, 0, canvas.width, canvas.height);
      fpsCounter++;
    };

    // FPS 统计
    setInterval(() => {
      const now = performance.now();
      const elapsed = (now - lastFpsTs) / 1000;
      const fps = (fpsCounter / elapsed).toFixed(1);
      fpsEl.textContent = `FPS: ${isFinite(fps) ? fps : "--"}`;
      fpsCounter = 0; lastFpsTs = now;
    }, 1000);

    // ============ Helpers ============
    function logLine(type, payload) {
      const time = new Date().toLocaleTimeString();
      const div = document.createElement("div");
      div.className = "mb-1";
      div.innerHTML = `<span class="text-slate-400">[${time}]</span> <span class="px-1 rounded bg-slate-200">${type}</span> <span class="text-slate-700"></span>`;
      const span = div.querySelector("span:last-child");
      try {
        span.textContent = typeof payload === "string" ? payload : JSON.stringify(payload);
      } catch {
        span.textContent = String(payload);
      }
      logs.appendChild(div);
      if (autoScroll.checked) logs.scrollTop = logs.scrollHeight;
    }

    function setStatus(s) {
      statusText.textContent = `状态：${s}`;
      statusDot.className = "dot " + (s === "运行中" ? "status-run" :
                                       s === "错误"   ? "status-error" : "status-idle");
    }
    function setMode(m) {
      mode = m;
      modeBadge.textContent = `模式：${m === "human" ? "Human(接管)" : "Agent"}`;
      modeBadge.className = "px-2 py-1 rounded text-sm " +
        (m === "human" ? "bg-amber-100 text-amber-800" : "bg-cyan-100 text-cyan-800");
      overlay.classList.toggle("show", m === "human");
      overlay.querySelector(".floating-tip").classList.toggle("opacity-0", m !== "human");
    }
    function setOperator(me) {
      operator = me;
      opBadge.classList.toggle("hidden", !me);
      btnHandover.disabled = me;
      btnRelease.disabled = !me;
    }

    function wsUrl(path) {
      const url = new URL(baseUrl.value);
      url.protocol = url.protocol.replace("http", "ws");
      url.pathname = path;
      return url.toString();
    }

    // ============ Buttons ============
    btnCreate.onclick = async () => {
      try {
        const res = await fetch(`${baseUrl.value}/sessions`, {
          method: "POST",
          headers: { "X-API-Key": apiKey.value }
        });
        if (!res.ok) throw new Error(await res.text());
        const data = await res.json();
        sessionId.value = data.session_id;
        logLine("session", `创建成功：${data.session_id}`);
      } catch (e) {
        logLine("error", e.message || e);
      }
    };

    btnConnect.onclick = async () => {
      const sid = sessionId.value.trim();
      if (!sid) return alert("请先填写 Session ID");

      // Events WS
      wsEvents = new WebSocket(wsUrl(`/sessions/${sid}/events`));
      wsEvents.onopen = () => { connected = true; setStatus("已连接"); logLine("events", "已连接"); };
      wsEvents.onmessage = (ev) => {
        try {
          const msg = JSON.parse(ev.data);
          if (msg.type === "step") logLine("step", msg.payload);
          else if (msg.type === "finished") { logLine("finished", msg.payload); setStatus("空闲"); }
          else if (msg.type === "error") { logLine("error", msg.payload); setStatus("错误"); }
          else if (msg.type === "pong") {
            const rtt = Math.round(performance.now() - lastRttSent);
            rttEl.textContent = `延迟: ${rtt} ms`;
          } else { logLine("evt", msg); }
        } catch {
          logLine("raw", ev.data);
        }
      };
      wsEvents.onclose = () => { setStatus("未连接"); connected = false; logLine("events", "已断开"); };

      // Screen WS
      wsScreen = new WebSocket(wsUrl(`/sessions/${sid}/screen`));
      wsScreen.onmessage = (ev) => {
        // base64 -> image
        frameImg.src = `data:image/jpeg;base64,${ev.data}`;
      };

      // Control WS
      wsControl = new WebSocket(wsUrl(`/sessions/${sid}/control`));
      wsControl.onopen = () => logLine("control", "已连接");
      wsControl.onmessage = (ev) => {
        try {
          const msg = JSON.parse(ev.data);
          if (msg.type === "granted") { setMode("human"); setOperator(true); logLine("control", "接管成功"); }
          else if (msg.type === "released") { setMode("agent"); setOperator(false); logLine("control", "已释放"); }
          else if (msg.type === "deny") { logLine("control", `接管被拒：${msg.reason||""}`); }
        } catch {}
      };

      // 拉一次状态
      try {
        const res = await fetch(`${baseUrl.value}/sessions/${sid}/status`, {
          headers: { "X-API-Key": apiKey.value }
        });
        if (res.ok) {
          const s = await res.json();
          setStatus(s.status === "running" ? "运行中" : s.status === "error" ? "错误" : "空闲");
          setMode(s.control_mode === "human" ? "human" : "agent");
        }
      } catch {}
    };

    btnHandover.onclick = () => {
      if (!wsControl || wsControl.readyState !== 1) return;
      wsControl.send(JSON.stringify({ type: "handover" }));
    };
    btnRelease.onclick = () => {
      if (!wsControl || wsControl.readyState !== 1) return;
      wsControl.send(JSON.stringify({ type: "release" }));
    };

    btnSendTask.onclick = async () => {
      const sid = sessionId.value.trim();
      if (!sid) return alert("Session ID 为空");
      if (!task.value.trim()) return alert("请输入任务内容");
      try {
        setStatus("运行中");
        const res = await fetch(`${baseUrl.value}/sessions/${sid}/task`, {
          method: "POST",
          headers: {
            "Content-Type": "application/json",
            "X-API-Key": apiKey.value
          },
          body: JSON.stringify({ task: task.value, max_steps: Number(maxSteps.value || 30) })
        });
        const data = await res.json();
        if (!res.ok) throw new Error(data.detail || JSON.stringify(data));
        logLine("task", "任务已提交");
      } catch (e) {
        setStatus("错误");
        logLine("error", e.message || e);
      }
    };

    btnClearLog.onclick = () => { logs.innerHTML = ""; };
    btnPing.onclick = () => {
      if (wsEvents && wsEvents.readyState === 1) {
        lastRttSent = performance.now();
        wsEvents.send("ping"); // 后端没特别处理也没关系
      }
    };
    btnSnap.onclick = () => {
      const a = document.createElement("a");
      a.download = `frame_${Date.now()}.png`;
      a.href = canvas.toDataURL("image/png");
      a.click();
    };

    // ============ Mouse / Keyboard injection ============
    // 只有“操作者”且焦点在画面时才允许键盘注入
    let focused = false;
    canvas.addEventListener("click", () => { focused = true; canvas.classList.add("ring-2","ring-indigo-400"); });
    document.addEventListener("click", (e) => {
      if (!canvas.contains(e.target)) { focused = false; canvas.classList.remove("ring-2","ring-indigo-400"); }
    });

    function sendMouse(name, e) {
      if (!wsControl || wsControl.readyState !== 1 || !operator) return;
      const rect = canvas.getBoundingClientRect();
      const rx = (e.clientX - rect.left) / rect.width;
      const ry = (e.clientY - rect.top) / rect.height;
      const msg = { type: "mouse", name, x: rx, y: ry, width: canvas.width, height: canvas.height };
      if (name === "mousePressed" || name === "mouseReleased") {
        msg.button = (e.button === 2 ? "right" : e.button === 1 ? "middle" : "left");
      }
      if (name === "mouseWheel") msg.deltaY = e.deltaY;
      wsControl.send(JSON.stringify(msg));
    }
    canvas.addEventListener("mousedown", (e) => sendMouse("mousePressed", e));
    canvas.addEventListener("mouseup",   (e) => sendMouse("mouseReleased", e));
    canvas.addEventListener("mousemove", (e) => sendMouse("mouseMoved", e), { passive:true });
    canvas.addEventListener("wheel",     (e) => { sendMouse("mouseWheel", e); e.preventDefault(); }, { passive:false });

    function sendKey(name, e) {
      if (!wsControl || wsControl.readyState !== 1 || !operator || !focused) return;
      const msg = { type: "key", name, key: e.key, code: e.code };
      wsControl.send(JSON.stringify(msg));
    }
    window.addEventListener("keydown", (e) => { if (focused) { e.preventDefault(); sendKey("keyDown", e); }}, true);
    window.addEventListener("keyup",   (e) => { if (focused) { e.preventDefault(); sendKey("keyUp",   e); }}, true);

    // 自适应：当页面变宽时，canvas CSS 宽度变，但实际像素不变；我们让高度跟随等比即可（绘制时会按目标像素铺满）
    const resize = () => {
      const ratio = canvas.height / canvas.width;
      canvas.style.height = (canvas.clientWidth * ratio) + "px";
    };
    resize();
    window.addEventListener("resize", resize);
  </script>
</body>
</html>
